Skip to content

S11-05 Vue-Composition

[TOC]

代码逻辑复用(logic reuse)一直是开发中(任何开发)一个非常重要的功能。

对于当前组件化盛行的前端开发来说,如何可以更多的对组件中的代码逻辑进行复用是一致在探索的一个话题,无论是在 React、Vue,还是在 Angular 中。

那么接下来,我们就一起来看一下目前比较常见的在 Vue 中可以实现组件代码复用的方式,并且会详细、深入的学习一下 Vue3 最新的 Composition API。

其实在 Vue2 当中的 Options API 中已经有了一些代码复用的方式,当然这些 API 在 Vue3 中依然是保留的,所以我们一起来学习一下。

Mixin 混入

认识 Mixin

目前我们是使用组件化的方式在开发整个 Vue 的应用程序,但是组件和组件之间有时候会存在相同的代码逻辑,我们希望对相同的代码逻辑进行抽取。

在 Vue2 和 Vue3 中都支持的一种方式就是使用 Mixin 来完成。

  • Mixin 提供了一种非常灵活的方式,来分发 Vue 组件中的可复用功能;
  • 一个 Mixin 对象可以包含任何组件选项;
  • 当组件使用 Mixin 对象时,所有 Mixin 对象的选项将被 混合 进入该组件本身的选项中;

比如我们封装一个 mixin 的对象在 sayHelloMixin.js 文件中:

js
const sayHelloMixin = {
  created() {
    this.sayHello()
  },
  methods: {
    sayHello() {
      console.log('Hello Page Component')
    }
  }
}

export default sayHelloMixin

之后,在 Home.vue 中通过 mixins 的选项进行混入:

html
<template>
  <div></div>
</template>

<script>
  import sayHelloMixin from '../mixins/sayHello'

  export default {
    mixins: [sayHelloMixin]
  }
</script>

<style scoped></style>

Mixin 合并

如果 Mixin 对象中的选项和组件对象中的选项发生了冲突,那么 Vue 会如何操作呢?

  • 这里分成不同的情况来进行处理;

情况一:如果是 data 函数的返回值对象

  • 返回值对象默认情况下会进行合并;
  • 如果 data 返回值对象的属性发生了冲突,那么会保留组件自身的数据;

mixin 中的代码:

js
const sayHelloMixin = {
  data() {
    return {
      name: 'mixin',
      age: 18
    }
  }
}

export default sayHelloMixin

Home.vue 中的代码:

html
<script>
  import sayHelloMixin from '../mixins/sayHello'

  export default {
    mixins: [sayHelloMixin],
    data() {
      return {
        message: 'Hello World',
        // 冲突时会保留组件中的name
        name: 'home'
      }
    }
  }
</script>

情况二:如何生命周期钩子函数

  • 生命周期的钩子函数会被合并到数组中,都会被调用;

mixin 中的代码:

js
const sayHelloMixin = {
  created() {
    console.log('mixin created')
  }
}

export default sayHelloMixin

Home.vue 中的代码:

html
<script>
  import sayHelloMixin from '../mixins/sayHello'

  export default {
    mixins: [sayHelloMixin],
    created() {
      console.log('home created')
    }
  }
</script>

情况三:值为对象的选项,例如 methodscomponentsdirectives,将被合并为同一个对象。

  • 比如都有 methods 选项,并且都定义了方法,那么它们都会生效;
  • 但是如果对象的 key 相同,那么会取组件对象的键值对;
  • 比如下面的代码中,最终 methods 对象会被合并成一个对象;

mixin 中的代码:

js
const sayHelloMixin = {
  methods: {
    sayHello() {
      console.log('Hello Page Component')
    },
    foo() {
      console.log('mixin foo function')
    }
  }
}

export default sayHelloMixin

Home.vue 中的代码:

html
<script>
  import sayHelloMixin from '../mixins/sayHello'

  export default {
    mixins: [sayHelloMixin],
    methods: {
      foo() {
        console.log('mixin foo function')
      },
      bar() {
        console.log('bar function')
      }
    }
  }
</script>

全局 Mixin

如果组件中的某些选项,是所有的组件都需要拥有的,那么这个时候我们可以使用全局的 mixin:

  • 全局的 Mixin 可以使用 应用 app 的方法 mixin 来完成注册;
  • 一旦注册,那么全局混入的选项将会影响每一个组件;
js
import { createApp } from 'vue'
import App from './14_Mixin混入/App.vue'

const app = createApp(App)
app.mixin({
  created() {
    console.log('global mixin created')
  }
})
app.mount('#app')

extends

另外一个类似于 Mixin 的方式是通过 extends 属性:

  • 允许声明扩展另外一个组件,类似于 Mixins;

我们开发一个 HomePage.vue 的组件对象:

html
<script>
  export default {
    data() {
      return {
        message: 'Hello Page'
      }
    }
  }
</script>

在 Home.vue 中我们可以继承自 HomePage.vue:

  • 注意:只可以继承自对象中的属性,不可以继承模板和样式等;
html
<script>
  import BasePage from './BasePage.vue'

  export default {
    extends: BasePage
  }
</script>

在开发中 extends 用的非常少,在 Vue2 中比较推荐大家使用 Mixin,而在 Vue3 中推荐使用 Composition API。

API

在 Vue2 中,我们编写组件的方式是 Options API:

  • Options API 的一大特点就是在对应的属性中编写对应的功能模块;
  • 比如 data 定义数据、methods 中定义方法、computed 中定义计算属性、watch 中监听属性改变,也包括生命周期钩子;

但是这种代码有一个很大的弊端:

  • 当我们实现某一个功能时,这个功能对应的代码逻辑会被拆分到各个属性中;
  • 当我们组件变得更大、更复杂时,逻辑关注点的列表就会增长,那么同一个功能的逻辑就会被拆分的很分散;
  • 尤其对于那些一开始没有编写这些组件的人来说,这个组件的代码是难以阅读和理解的(阅读组件的其他人);

下面我们来看一个非常大的组件,其中的逻辑功能按照颜色进行了划分:

  • 这种碎片化的代码使用理解和维护这个复杂的组件变得异常困难,并且隐藏了潜在的逻辑问题;
  • 并且当我们处理单个逻辑关注点时,需要不断的 跳转 到响应的代码块中;

如果我们能将同一个逻辑关注点相关的代码收集在一起会更好,这就是 Composition API 想要做的事情,以及可以帮助我们完成的事情。

那么既然知道 Composition API 想要帮助我们做什么事情,接下来看一下到底是怎么做呢?

  • 为了开始使用 Composition API,我们需要有一个可以实际使用它(编写代码)的地方;
  • 在 Vue 组件中,这个位置就是 setup 函数;

API

  • 响应式:核心
  • setup() :在组件中使用组合式 API 的入口
  • ref() :接受一个内部值,返回一个响应式的、可更改的 ref 对象,此对象只有一个指向其内部值的属性 .value
  • computed() :接受一个 getter 函数,返回一个只读的响应式 ref 对象。该 ref 通过 .value 暴露 getter 函数的返回值。它也可以接受一个带有 get 和 set 函数的对象来创建一个可写的 ref 对象。
  • reactive() :返回一个对象的响应式代理
  • readonly() :接受一个对象 (不论是响应式还是普通的) 或是一个 ref,返回一个原值的只读代理
  • watchEffect() :立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时重新执行
  • watchPostEffect() :watchEffect() 使用 flush: 'post' 选项时的别名
  • watchSyncEffect() :watchEffect() 使用 flush: 'sync' 选项时的别名
  • watch() :侦听一个或多个响应式数据源,并在数据源变化时调用所给的回调函数
  • 响应式: 工具
  • isRef() :检查某个值是否为 ref
  • unref() :如果参数是 ref,则返回内部值,否则返回参数本身。是 val = isRef(val) ? val.value : val 计算的一个语法糖
  • toRef() :基于响应式对象上的一个属性,创建一个对应的 ref。这样创建的 ref 与其源属性保持同步:改变源属性的值将更新 ref 的值,反之亦然。
  • toRefs() :将一个响应式对象转换为一个普通对象,这个普通对象的每个属性都是指向源对象相应属性的 ref。每个单独的 ref 都是使用 toRef() 创建的。
  • isProxy() :检查一个对象是否是由 reactive()、readonly()、shallowReactive() 或 shallowReadonly() 创建的代理。
  • isReactive() :检查一个对象是否是由 reactive() 或 shallowReactive() 创建的代理。
  • isReadonly() :检查传入的值是否为只读对象。只读对象的属性可以更改,但他们不能通过传入的对象直接赋值。通过 readonly() 和 shallowReadonly() 创建的代理都是只读的,因为他们是没有 set 函数的 computed() ref。
  • 响应式: 进阶
  • shallowRef() :ref() 的浅层作用形式。
  • triggerRef() :强制触发依赖于一个浅层 ref 的副作用,这通常在对浅引用的内部值进行深度变更后使用。
  • customRef() :创建一个自定义的 ref,显式声明对其依赖追踪和更新触发的控制方式。
  • shallowReactive() :reactive() 的浅层作用形式。
  • shallowReadonly() :readonly() 的浅层作用形式
  • toRaw() :根据一个 Vue 创建的代理返回其原始对象。
  • markRaw() :将一个对象标记为不可被转为代理。返回该对象本身。
  • effectScope() :创建一个 effect 作用域,可以捕获其中所创建的响应式副作用 (即计算属性和侦听器),这样捕获到的副作用可以一起处理。对于该 API 的使用细节,请查阅对应的 RFC。
  • getCurrentScope() :如果有的话,返回当前活跃的 effect 作用域。
  • onScopeDispose() :在当前活跃的 effect 作用域上注册一个处理回调函数。当相关的 effect 作用域停止时会调用这个回调函数。
  • 生命周期钩子
  • onMounted() :注册一个回调函数,在组件挂载完成后执行。
  • onUpdated() :注册一个回调函数,在组件因为响应式状态变更而更新其 DOM 树之后调用。
  • onUnmounted() :注册一个回调函数,在组件实例被卸载之后调用。
  • onBeforeMount() :注册一个钩子,在组件被挂载之前被调用。
  • onBeforeUpdate() :注册一个钩子,在组件即将因为响应式状态变更而更新其 DOM 树之前调用。
  • onBeforeUnmount() :注册一个钩子,在组件实例被卸载之前调用。
  • onErrorCaptured() :注册一个钩子,在捕获了后代组件传递的错误时调用。
  • onRenderTracked() :注册一个调试钩子,当组件渲染过程中追踪到响应式依赖时调用。这个钩子仅在开发模式下可用,且在服务器端渲染期间不会被调用。
  • onRenderTriggered() :注册一个调试钩子,当响应式依赖的变更触发了组件渲染时调用。这个钩子仅在开发模式下可用,且在服务器端渲染期间不会被调用。
  • onActivated() :注册一个回调函数,若组件实例是 <KeepAlive> 缓存树的一部分,当组件被插入到 DOM 中时调用。这个钩子在服务器端渲染期间不会被调用。
  • onDeactivated() :注册一个回调函数,若组件实例是 <KeepAlive> 缓存树的一部分,当组件从 DOM 中被移除时调用。这个钩子在服务器端渲染期间不会被调用。
  • onServerPrefetch() :注册一个异步函数,在组件实例在服务器上被渲染之前调用。
  • 依赖注入
  • provide() :提供一个值,可以被后代组件注入。
  • inject() :注入一个由祖先组件或整个应用 (通过 app.provide()) 提供的值。

setup 函数基本使用

setup 其实就是组件的另外一个选项:

  • 只不过这个选项强大到我们可以用它来替代之前所编写的大部分其他选项;
  • 比如 methods、computed、watch、data、生命周期等等;

1、语法

html
<script>
    export default {
      props: {
        message: String
      },
  +    setup(props, context) {
  +      const count = ref(0)
  +      console.log(props.message);

  +      return { count }
  +    }
    }
</script>

2、参数

  • props:父组件传递过来的属性会被放到 props 对象中
  • context{ attrs, slots, emit, expose } Setup 上下文对象。上下文对象暴露了其他一些在 setup 中可能会用到的值
    • attrs:所有的非 prop 的 attribute
    • slots:父组件传递过来的插槽(这个在以渲染函数返回时会有作用,后面会讲到)
    • emit:当我们组件内部需要发出事件时会用到 emit(因为我们不能访问 this,所以不可以通过 this.$emit发出事件)
    • expose:用于显式地限制该组件暴露出的属性,当父组件通过模板引用访问该组件的实例时,将仅能访问 expose 函数暴露出的内容

3、返回值

  • ref 对象return { count } 它会自动浅层解包,因此你无须再在模板中为它写 .value。当通过 this 访问时也会同样如此解包
  • 渲染函数return () => h('div', count.value)

setup 函数的参数

我们先来研究一个 setup 函数的参数,它主要有两个参数:

  • 第一个参数:props
  • 第二个参数:context

1、参数:props

props 非常好理解,它其实就是父组件传递过来的属性会被放到 props 对象中,我们在 setup 中如果需要使用,那么就可以直接通过 props 参数获取。

我们来看一个 ShowMessage.vue 的组件:

  • 这个组件接受一个 message 的 props;
  • 对于定义 props 的类型,我们还是和之前的规则是一样的,在 props 选项中定义;
  • 并且在 template 中依然是可以正常去使用 props 中的 message 的;
  • 如果我们在 setup 函数中想要使用 props,那么不可以通过 this 去获取(后面我会讲到为什么);
  • 因为 props 有直接作为参数传递到 setup 函数中,所以我们可以直接通过参数来使用即可;
html
<template>
  <div>
    <h2>{{message}}</h2>
  </div>
</template>

<script>
  export default {
    props: {
      message: String
    },
    setup(props) {
      console.log(props.message)
    }
  }
</script>

2、参数:context

另外一个参数是 context,我们也称之为是一个 SetupContext,它里面包含三个属性:

  • attrs:所有的非 prop 的 attribute
  • slots:父组件传递过来的插槽(这个在以渲染函数返回时会有作用,后面会讲到)
  • emit:当我们组件内部需要发出事件时会用到 emit(因为我们不能访问 this,所以不可以通过 this.$emit发出事件)
  • expose:用于显式地限制该组件暴露出的属性,当父组件通过模板引用访问该组件的实例时,将仅能访问 expose 函数暴露出的内容

在 App.vue 中按照如下方式使用 ShowMessage.vue 组件:

html
<template>
  <div>
    <show-message message="Hello World" id="why" class="kobe">
      <template #default>
        <span>哈哈哈</span>
      </template>
      <template #content>
        <span>呵呵呵</span>
      </template>
    </show-message>
  </div>
</template>

我们在 ShowMessage.vue 中获取传递过来的内容:

html
<script>
  export default {
    props: {
      message: String
    },
    setup(props, context) {
      console.log(props.message)
      // 获取attrs
      console.log(context.attrs.id, context.attrs.class)
      console.log(context.slots.default)
      console.log(context.slots.content)
      console.log(context.emit)
    }
  }
</script>

当然,目前我们并没有具体演示 slots 和 emit 的用法:

  • slots 我会在后续讲解 render 函数时使用;
  • emit 我会在待会儿讲到组件内发出事件时使用;

setup 函数的返回值

setup 既然是一个函数,那么它也可以有返回值,它的返回值用来做什么呢?

  • setup 的返回值可以在模板 template 中被使用;
  • 也就是说我们可以通过 setup 的返回值来替代 data 选项;
html
<template>
  <div>
    <h2>{{name}}</h2>
    <h2>当前计数: {{counter}}</h2>
  </div>
</template>

<script>
  export default {
    props: {
      message: String
    },
    setup(props, context) {
      const name = 'coderwhy'
      let counter = 100

      return {
        name,
        counter
      }
    }
  }
</script>

甚至是我们可以返回一个执行函数来代替在 methods 中定义的方法:

html
<template>
  <div>
    <h2>{{name}}</h2>
    <h2>当前计数: {{counter}}</h2>
    <button @click="increment">+1</button>
    <button @click="decrement">-1</button>
  </div>
</template>

<script>
  export default {
    props: {
      message: String
    },
    setup(props, context) {
      const name = 'coderwhy'
      let counter = 100

      const increment = () => {
        console.log('increment')
      }
      const decrement = () => {
        console.log('decrement')
      }

      return {
        name,
        counter,
        increment,
        decrement
      }
    }
  }
</script>

但是,如果我们将 counterincrement 或者 decrement进行操作时,是否可以实现界面的响应式呢?

  • 答案是不可以;
  • 这是因为对于一个定义的变量来说,默认情况下,Vue 并不会跟踪它的变化,来引擎界面的响应式操作;

那么我们应该怎么做呢?接下来我们就学习一下 setup 中数据的响应式。

setup 函数的 this

官方关于 this 有这样一段描述(这段描述是我给官方提交了 PR 之后的一段描述):

  • 表达的含义是 this 并没有指向当前组件实例;
  • 并且在 setup 被调用之前,data、computed、methods 等都没有被解析;
  • 所以无法在 setup 中获取 this;

image-20230209130235321

关于 this 的描述

其实在之前的这段描述是和源码有出入的(我向官方提交了 PR,做出了描述的修改):

  • 之前的描述大概含义是不可以使用 this 是因为组件实例还没有被创建出来;
  • 后来我的 PR 也有被合并到官方文档中;

image-20230209130248294

我提出的一个 PR

我是如何发现官方文档的错误的呢?

在阅读源码的过程中,代码是按照如下顺序执行的:

  • 调用 createComponentInstance 创建组件实例;
  • 调用 setupComponent 初始化 component 内部的操作;
  • 调用 setupStatefulComponent 初始化有状态的组件;
  • setupStatefulComponent 取出了 setup 函数;
  • 通过callWithErrorHandling 的函数执行 setup

image-20230209130310346

setup 函数的执行

从上面的代码我们可以看出, 组件的 instance 肯定是在执行 setup 函数之前就创建出来的。

setup 数据的响应式

reactive API

如果想为在 setup 中定义的数据提供响应式的特性,那么我们可以使用 reactive 的函数:

html
<template>
  <div>
    <h2>{{state.name}}</h2>
    <h2>当前计数: {{state.counter}}</h2>
    <button @click="increment">+1</button>
    <button @click="decrement">-1</button>
  </div>
</template>

<script>
    import { reactive } from 'vue';

    export default {
      setup() {
         // 1. 定义reactive
  +      const state = reactive({
  +        name: "coderwhy",
  +        counter: 100
  +      })

         // 2. 访问和修改reactive
  +      const increment = () => state.counter++;
  +      const decrement = () => state.counter--;

        return {
          state,
          increment,
          decrement
        }
      }
    }
</script>

也就是我们按照如下的方式在 setup 中使用数据,就可以让数据变成响应式的了:

js
import { reactive } from 'vue'

// 响应式状态
const state = reactive({
  count: 0
})

那么这是什么原因呢?为什么就可以变成响应式的呢?

  • 这是因为当我们使用 reactive 函数处理我们的数据之后,数据再次被使用时就会进行依赖收集;
  • 当数据发生改变时,所有收集到的依赖都是进行对应的响应式操作(比如更新界面);
  • 事实上,我们编写的 data 选项,也是在内部交给了 reactive 函数将其编程响应式对象的;

ref API

reactive API 对传入的类型是有限制的,它要求我们必须传入的是一个对象或者数组类型:

  • 如果我们传入一个基本数据类型(String、Number、Boolean)会报一个警告;

image-20230209130327424

reactive 传入基本数据类型

这个时候 Vue3 给我们提供了另外一个 API:ref API

  • ref 会返回一个可变的额响应式对象,该对象作为一个 响应式的引用 维护着它内部的值,这就是 ref 名称的来源;
  • 它内部的值是在 ref 的 value 的属性中被维护的;

接下来我们看一下 Ref 的 API 是如何使用的:

html
<template>
  <div>
    <h2>{{message}}</h2>
    <button @click="changeMessage">changeMessage</button>
  </div>
</template>

<script>
  import { ref } from 'vue'

  export default {
    setup() {
      const message = ref('Hello World')
      const changeMessage = () => (message.value = '你好啊, 李银河')

      return {
        message,
        changeMessage
      }
    }
  }
</script>

这里有两个注意事项:

  • 在模板中引入 ref 的值时,Vue 会自动帮助我们进行解包操作,所以我们并不需要在模板中通过 ref.value 的方式来使用;
  • 但是在 setup 函数内部,它依然是一个 ref引用, 所以对其进行操作时,我们依然需要使用 ref.value的方式;

但是,模板中的解包是浅层的解包,如果我们的代码是下面的方式:

image-20230209130340358

深层对象不会解包

但是,如果我们将 ref 放到一个 reactive 的属性当中,那么它会自动解包:

image-20230209130353359

reactive 对象会解包

reactive 知识点补充

readonly

我们通过 reactive 或者 ref 可以获取到一个响应式的对象,但是某些情况下,我们传入给其他地方的这个响应式对象希望在另外一个地方被使用,但是不能被修改,这个时候如何防止这种情况的出现呢?

  • Vue3 为我们提供了 readonly 的方法;
  • readonly 会返回原生对象的只读代理(也就是它依然是一个 Proxy,这是一个 proxy 的 set 方法被劫持,并且不需要对其进行修改);

在开发中常见的 readonly 方法会传入三个类型的参数:

  • 类型一:普通对象;
  • 类型二:reactive 返回的对象;
  • 类型三:ref 的对象;

在 readonly 的使用过程中,有如下规则:

  • readonly 返回的对象都是不允许修改的;

  • 但是经过 readonly 处理的原来的对象是允许被修改的;

    • 比如 const info = readonly(obj),info 对象是不允许被修改的;
    • 当 obj 被修改时,readonly 返回的对象也会被修改;
    • 但是我们不能去修改 readonly 返回的对象;
  • 其实本质上就是 readonly 返回的对象的 setter 方法被劫持了而已;

html
<script>
  export default {
    setup() {
      // readonly通常会传入三个类型的数据
      // 1.传入一个普通对象
      const info = {
        name: 'why',
        age: 18
      }
      const state1 = readonly(info)

      console.log(state1)

      // 2.传入reactive对象
      const state = reactive({
        name: 'why',
        age: 18
      })
      const state2 = readonly(state)

      // 3.传入ref对象
      const nameRef = ref('why')
      const state3 = readonly(nameRef)

      return {
        state2,
        changeName
      }
    }
  }
</script>

那么这个 readonly 有什么用呢?

  • 在我们传递给其他组件数据时,往往希望其他组件使用我们传递的内容,但是不允许它们修改时,就可以使用 readonly 了;

image-20230209130410398

Home 中修改 App 的 info

这个时候我们可以传递给子组件时,使用一个 readonly 数据:

  • 子组件在修改 readonly 数据的时候就无法进行修改了;

image-20230209130426037

传递 readonly 数据

isProxy

检查对象是否是由 reactivereadonly创建的 proxy。

isReactive

检查对象是否是由 reactive创建的响应式代理:

js
import { reactive, isReactive } from 'vue'
export default {
  setup() {
    const state = reactive({
      name: 'John'
    })
    console.log(isReactive(state)) // -> true
  }
}

如果该代理是 readonly 创建的,但包裹了由 reactive 创建的另一个代理,它也会返回 true:

js
import { reactive, isReactive, readonly } from 'vue'
export default {
  setup() {
    const state = reactive({
      name: 'John'
    })
    // 从普通对象创建的只读 proxy
    const plain = readonly({
      name: 'Mary'
    })
    console.log(isReactive(plain)) // -> false

    // 从响应式 proxy 创建的只读 proxy
    const stateCopy = readonly(state)
    console.log(isReactive(stateCopy)) // -> true
  }
}

isReadonly

检查对象是否是由 readonly 创建的只读代理。

toRaw

返回 reactivereadonly 代理的原始对象。

  • 建议保留对原始对象的持久引用。请谨慎使用。
js
const info = { name: 'why' }
const reactiveInfo = reactive(info)

console.log(toRaw(reactiveInfo) === info) // true

shallowReactive

创建一个响应式代理,它跟踪其自身 property 的响应性,但不执行嵌套对象的深层响应式转换 (深层还是原生对象)。

js
const state = shallowReactive({
  foo: 1,
  nested: {
    bar: 2
  }
})

// 改变 state 本身的性质是响应式的
state.foo++
// ...但是不转换嵌套对象
isReactive(state.nested) // false
state.nested.bar++ // 非响应式

shallowReadonly

创建一个 proxy,使其自身的 property 为只读,但不执行嵌套对象的深度只读转换(深层还是可读、可写的)。

js
const state = shallowReadonly({
  foo: 1,
  nested: {
    bar: 2
  }
})

// 改变 state 本身的 property 将失败
state.foo++
// ...但适用于嵌套对象
isReadonly(state.nested) // false
state.nested.bar++ // 可用

ref 知识点补充

toRefs

如果我们使用 ES6 的解构语法,对 reactive 返回的对象进行解构获取值,那么之后无论是修改解构后的变量,还是修改 reactive 返回的 state 对象,数据都不再是响应式的:

html
<script>
  import { ref, reactive } from 'vue'

  export default {
    setup() {
      const state = reactive({
        name: 'why',
        age: 18
      })

      const { name, age } = state

      const changeName = () => (state.name = 'coderwhy')

      return {
        name,
        age,
        changeName
      }
    }
  }
</script>

那么有没有办法让我们解构出来的属性是响应式的呢?

  • Vue 为我们提供了一个 toRefs 的函数,可以将 reactive 返回的对象中的属性都转成 ref;
  • 那么我们再次进行解构出来的 nameage 本身都是 ref 的;
js
// 当我们这样来做的时候, 会返回两个ref对象, 它们是响应式的
const { name, age } = toRefs(state)

// 下面两种方式来修改name都是可以的
const changeName = () => (name.value = 'coderwhy')
const changeName = () => (state.name = 'coderwhy')

这种做法相当于已经在 state.name 和 ref.value 之间建立了 链接,任何一个修改都会引起另外一个变化;

toRef

如果我们只希望转换一个 reactive 对象中的属性为 ref, 那么可以使用 toRef 的方法:

js
const name = toRef(state, 'name')
const { age } = state
const changeName = () => (state.name = 'coderwhy')

unref

如果我们想要获取一个 ref 引用中的 value,那么也可以通过 unref 方法:

  • 如果参数是一个 ref,则返回内部值,否则返回参数本身;
  • 这是 val = isRef(val) ? val.value : val 的语法糖函数;
js
import { ref, unref } from 'vue'

const name = ref('why')
console.log(unref(name)) // why

isRef

判断值是否是一个 ref 对象。

customRef

创建一个自定义的 ref,并对其依赖项跟踪和更新触发进行显示控制:

  • 它需要一个工厂函数,该函数接受 tracktrigger 函数作为参数;
  • 并且应该返回一个带有 getset 的对象;

这里我们使用一个官方的案例:

  • 对双向绑定的属性进行 debounce(节流)的操作;

封装 useDebouncedRef 的工具 Hook:

js
import { customRef } from 'vue'

export function useDebouncedRef(value, delay = 200) {
  let timeout
  return customRef((track, trigger) => {
    return {
      get() {
        track()
        return value
      },
      set(newValue) {
        clearTimeout(timeout)
        timeout = setTimeout(() => {
          value = newValue
          trigger()
        }, delay)
      }
    }
  })
}

在组件界面中使用:

html
<template>
  <div>
    <input v-model="message" />
    <h2>{{message}}</h2>
  </div>
</template>

<script>
  import { useDebouncedRef } from '../hooks/useDebounceRef'

  export default {
    setup() {
      const message = useDebouncedRef('Hello World')
      return {
        message
      }
    }
  }
</script>

shallowRef

创建一个浅层的 ref 对象:

js
const info = shallowRef({ name: 'why' })

// 下面的修改不是响应式的
const changeInfo = () => (info.value.name = 'coderwhy')

triggerRef

手动触发和 shallowRef 相关联的副作用:

js
const info = shallowRef({ name: 'why' })

// 下面的修改不是响应式的
const changeInfo = () => {
  info.value.name = 'coderwhy'
  // 手动触发
  triggerRef(info)
}

前面给大家分享了 Options API 语法中代码的复用、Options API 编码的优缺点,以及 setup 函数,响应式 API 等,这次将给大家分享 Vue3 Composition API 中的计算属性,侦听器,生命周期函数,Provide 和 Inject 等。

computed

在前面我们讲解过计算属性 computed:当我们的某些属性是依赖其他状态时,我们可以使用计算属性来处理

  • 在前面的 Options API 中,我们是使用computed选项来完成。
  • 在 Composition API 中,我们可以在 setup 函数中使用computed函数来编写一个计算属性。

如何使用 computed 函数呢?

  • 方式一:接收一个 getter 函数,并根据 getter 的返回值返回一个不可变的响应式 ref 对象。
  • 方式二:接收一个具有 get 和 set 方法的对象,返回一个可变的(可读写)ref 对象。

computed 基本使用

定义 reactive 响应式数据

html
<template>
  <div class="app">
    <!-- 4. 在模板中使用 fullName -->
    <div>{{ fullName }}</div>
  </div>
</template>

<script>
  import { reactive, computed } from 'vue'
  export default {
    setup() {
      // 1. 定义 reactive 响应式数据
      const names = reactive({
        firstName: 'Tom',
        lastName: 'Chang'
      })

      // 2. 通过 computed 函数计算出 fullName
      const fullName = computed(() => names.firstName + ' ' + names.lastName)

      // 3. 返回计算得到的 fullName
      return {
        fullName
      }
    }
  }
</script>

定义 ref 响应式数据,并修改 score 的值,此时通过 computed 计算得到的 scoreLv 的值也会随之改变

html
<template>
  <div class="app">
    <!-- 4. 展示 scoreLv -->
    <div>成绩级别:{{ scoreLv }}</div>
    <button @click="changeScore">修改成绩</button>
  </div>
</template>

<script>
  import { ref, computed } from 'vue'

  export default {
    setup() {
      // 1. 定义 ref 响应式数据
      const score = ref(67)

      // 2. 通过 computed 计算 scoreLv
      const scoreLv = computed(() => (score.value > 60 ? '及格' : '不及格'))

      // 5. 修改成绩
      function changeScore() {
        score.value = 50
      }

      // 3. 返回数据
      return {
        scoreLv,
        changeScore
      }
    }
  }
</script>

下面我们来看看 computed 函数的基本使用:接收一个 getter 函数。

首先使用 Vue CLI 新建一个01_composition_api的 Vue3 项目,然后在01_composition_api项目的src目录下新建07_computed使用文件夹,然后在该文件夹下分别新建:App.vue,ComputedAPI.vue组件。

ComputedAPI.vue 子组件,代码如下所示:

html
<template>
  <div>
    <!-- 2.使用fullName计算属性 -->
    <h4>{{fullName}}</h4>
    <button @click="changeName">修改firstName</button>
  </div>
</template>

<script>
  import { ref, computed } from 'vue'

  export default {
    setup() {
      const firstName = ref('Kobe')
      const lastName = ref('Bryant')

      // 1.用法一: 传入一个getter函数。computed的返回值是一个ref对象
      const fullName = computed(() => firstName.value + ' ' + lastName.value)

      const changeName = () => {
        // 3.修改firstName
        firstName.value = 'James'
      }
      return {
        fullName,
        changeName
      }
    }
  }
</script>

可以看到,我们使用了 computed 函数来定义了一个 fullName 计算属性,其中 computed 函数需要接收一个 getter 函数,我们在 getter 函数中对响应式的数据进行计算和返回。

App.vue 根组件,代码如下所示(省略了组件注册的代码):

html
<template>
  <div class="app" style="border:1px solid #ddd;margin:4px">
    App组件
    <ComputedAPI></ComputedAPI>
  </div>
</template>
.....

然后我们修改 main.js 程序入口文件,将导入的 App 组件改为07_computed使用/App.vue路径下的 App 组件。

保存代码,运行在浏览器的效果,如图 10-16 所示。计算属性可以正常显示,当点击修改 firstName 按钮时也可以响应式刷新页面。

image-20230209130440134

图 10-16 computed 函数的基本使用

计算属性 get 和 set 方法

接着我们再来看看 computed 函数的 get 和 set 方法的使用:接收一个对象,里面包含 setget方法。

修改 ComputedAPI.vue 子组件,代码如下所示:

html
......
<script>
  import { ref, computed } from 'vue'
  export default {
    setup() {
      const firstName = ref('Kobe')
      const lastName = ref('Bryant')
      // const fullName = computed(() => firstName.value + " " + lastName.value);

      // 1.用法二: 传入一个对象, 对象包含getter/setter
      const fullName = computed({
        get: () => firstName.value + ' ' + lastName.value, // getter 方法
        set(newValue) {
          // setter 方法
          const names = newValue.split(' ')
          firstName.value = names[0]
          lastName.value = names[1]
        }
      })

      const changeName = () => {
        // firstName.value = "James"
        // 3.修改fullName计算属性
        fullName.value = 'James Bryant'
      }
      return {
        fullName,
        changeName
      }
    }
  }
</script>

可以看到,我们使用了 computed 函数来定义了一个 fullName 计算属性,其中 computed 函数接收一个具有 get 和 set 方法的对象,我们在 get 方法中对响应式的数据进行计算和返回,在 set 方法中对传入的新值重新赋值给 firstName 和 lastName 响应式对象的值。

保存代码,运行在浏览器后。fullName 计算属性可以正常显示,当点击修改 firstName 按钮时也可以响应式刷新页面。

ref 引用 DOM 元素

1、基础用法

html
<template>
  <div class="app">
    <div class="opr">
      <el-button type="primary" @click="getRef">获取titleRef</el-button>
    </div>
    <hr />
    <!-- 3. 在渲染模板的时候会自动将 titleRef 挂载到 ref 属性上 -->
    <div class="title" ref="titleRef">我是标题</div>
  </div>
</template>

<script>
  import { ref } from 'vue'

  export default {
    setup() {
      // 1. 定义一个 ref 对象 titleRef
      const titleRef = ref()

      // 4. 在一开始的setup函数中 titleRef 对象值为 undefined
      console.log(titleRef.value) // undefined

      // 5. 在监听的事件 getRef 或者 onMounted 等生命周期中可以获取到 titleRef 的值
      function getRef() {
        console.log(titleRef.value) // <div class="title">我是标题</div>
      }
      onMounted(() => {
        console.log(titleRef.value)
      })

      // 2. 返回 titleRef
      return {
        titleRef,
        getRef
      }
    }
  }
</script>

2、获取组件实例

  • showInfoRef.value.$el: 获取组件 showInfo 的根元素 <div class="show-info"> show-info </div>
  • showInfoRef.value.foo():调用组件 showInfo 中的方法 foo
  • showInfoRef.value.msg:获取组件 showInfo 中的属性 msg

注意: 在 script setup 语法糖写法中,想要调用子组件的方法或属性,需要通过 defineExpose() 函数将方法和属性暴露出来才能调用

html
<template>
  <div class="app">
    <div class="opr">
      <el-button type="primary" @click="getRef">获取showInfoRef</el-button>
    </div>
    <hr />
    <!-- 3. 在渲染模板的时候会自动将 showInfoRef 挂载到 ref 属性上 -->
    <show-info ref="showInfoRef"></show-info>
  </div>
</template>

<script>
  import ShowInfo from './show-info.vue'
  import { onMounted, ref } from 'vue'

  export default {
    components: {
      ShowInfo
    },
    setup() {
      // 1. 定义一个 ref 对象 showInfoRef
      const showInfoRef = ref()

      // 4. 在一开始的setup函数中 showInfoRef 对象值为 undefined
      console.log(showInfoRef.value) // undefined

      // 5. 在监听的事件或者onMounted等生命周期中可以获取到 showInfoRef的值
      function getRef() {
        // 6. 获取组件Proxy
        console.log(showInfoRef.value) + // Proxy {…} show-info组件Proxy // 6. $el 获取组件的根元素
          +console.log(showInfoRef.value.$el) + // <div class="show-info"> show-info </div> // 6. 调用组件中的方法 foo
          +showInfoRef.value.foo() + // foo fn // 6. 获取组件中的属性 msg
          +console.log(showInfoRef.value.msg)
      }
      onMounted(() => {
        // ...和getRef一样
      })

      // 2. 返回 showInfoRef
      return {
        showInfoRef,
        getRef
      }
    }
  }
</script>

watchEffect 侦听

在前面的 Options API 中,我们可以通过watch选项来侦听 data,props 或者 computed 的数据变化,当数据变化时执行某一些操作。

在 Composition API 中,我们可以使用watchEffectwatch来完成响应式数据的侦听。

  • watchEffect 用于自动收集响应式数据的依赖。
  • watch 需要手动指定侦听的数据源。

下面我们先来看看 watchEffect 函数的基本使用。

watchEffect 基本使用

当侦听到某些响应式数据变化时,我们希望执行某些操作,这个时候可以使用 watchEffect

  • 首先,watchEffect 传入的函数会被立即执行一次,并且在执行的过程中会收集依赖。
  • 其次,只有收集的依赖发生变化时,watchEffect 传入的函数才会再次执行。

下面通过一个案例来学习 watchEffect 基本使用。我们在01_composition_api项目的src目录下新建08_watch使用文件夹,然后在该文件夹下分别新建:App.vue,WatchEffectAPI.vue组件。

WatchEffectAPI.vue 子组件,代码如下所示:

html
<template>
  <div>
    <h4>{{age}}</h4>
    <button @click="changeAge">修改age</button>
  </div>
</template>

<script>
  import { ref, watchEffect } from 'vue'
  export default {
    setup() {
      const age = ref(18)
      // watchEffect: 1.自动收集响应式的依赖 2.默认会先执行一次 3.获取不到新值和旧值
      watchEffect(() => {
        console.log('age:', age.value) // 侦听age的改变, age发生变化后会再次执行
      })

      const changeAge = () => age.value++
      return {
        age,
        changeAge
      }
    }
  }
</script>

可以看到,我们在 setup 函数中调用了 watchEffect 函数,并给该函数传递了一个回调函数,传入的回调函数会被立即执行一次,并且在执行的过程中会收集依赖(收集 age 的依赖)。当收集的依赖发生变化时,watchEffect 传入的回调函数又会再次执行。

App.vue 根组件,代码如下所示:

html
<template>
  <div class="app" style="border:1px solid #ddd;margin:4px">
    App组件
    <WatchEffectAPI></WatchEffectAPI>
  </div>
</template>
.....

然后我们修改 main.js 程序入口文件,将导入的 App 组件改为08_watch使用/App.vue路径下的 App 组件。

保存代码,运行在浏览器的效果,如图 10-17 所示。可以看到,默认会先执行一次打印 age:18,当点击修改 age 按钮来改变 age 时,watchEffect 侦听到 age 发生改变后,回调函数又会再次执行,并打印 age:19。

image-20230209130453776

图 10-17 watchEffect 基本使用

watchEffect 停止侦听

如果在发生某些情况下,我们希望停止侦听,这个时候我们可以获取 watchEffect 的返回值函数,调用该函数即可。

比如在上面的案例中,我们 age 达到 20 的时候就停止侦听,WatchEffectAPI.vue 子组件,代码如下所示:

html
....
<script>
  import { ref, watchEffect } from 'vue'
  export default {
    setup() {
      const age = ref(18)
      // 1.stop是watchEffect返回值的函数,用来停止侦听
      const stop = watchEffect(() => {
        console.log('age:', age.value) // 侦听age的改变
      })

      const changeAge = () => {
        age.value++
        if (age.value > 20) {
          stop() // 2.停止侦听age的变化
        }
      }
      return { age, changeAge }
    }
  }
</script>

保存代码,运行在浏览器后,可以看到默认会先执行一次打印 age:18,当点击修改 age 按钮来改变 age 时,当 age 大于 20 的时候,由于调用了 watchEffect 返回的 stop 函数,watchEffect 将会取消对 age 变量的侦听。

watchEffect清除副作用

什么是清除副作用呢?

  • 比如在开发中我们需要在侦听函数中执行网络请求,但是在网络请求还没有达到的时候,我们停止了侦听器,或者侦听器侦听函数被再次执行了。
  • 那么上一次的网络请求应该被取消掉(类似前面讲的防抖),这个时候我们就可以清除上一次的副作用。

在我们给watchEffect传入的函数被回调时,其实可以获取到一个参数:onInvalidate

  • 副作用即将再次重新执行 或者 侦听器被停止 时会执行onInvalidate函数传入的回调函数。
  • 我们可以在传入的回调函数中,执行一些清除的工作。

我们在08_watch使用文件夹下新建:WatchEffectAPIClear.vue组件。

WatchEffectAPIClear.vue子组件,代码如下所示(省略的template和上面案例一样):

html
......
<script>
  import { ref, watchEffect } from 'vue';
  export default {
    setup() {
      const age = ref(18);
      watchEffect((onInvalidate) => {
          
        const timer = setTimeout(() => {
          console.log("模拟网络请求,网络请求成功~");
        }, 2000)

        onInvalidate(() => {
          // 当侦听到age发生变化和侦听停止时会执行该这里代码,并在该函数中清除额外的副作用
          clearTimeout(timer); // age发生改变时,优先清除上一次定时器的副作用
          console.log("onInvalidate");
        })
        console.log("age:", age.value); // 侦听age的改变
      });

      const changeAge = () => age.value++
      return {age,changeAge}
    }
  }
</script>

可以看到,watchEffect函数传入的回调函数接收一个onInvalidate参数,onInvalidate也是一个函数,并且该函数也需要接收一个回调函数作为参数。

App.vue根组件,代码如下所示:

html
<template>
  <div class="app" style="border:1px solid #ddd;margin:4px">
    App组件
    <!-- <WatchEffectAPI></WatchEffectAPI> -->
    <WatchEffectAPIClear></WatchEffectAPIClear>
  </div>
</template>

保存代码,运行在浏览器的效果,如图10-18所示。刷新页面,立马连续点击3次修改age,我们可以看到watchEffect函数侦听到age改变了3次,并在每次将重新执行watchEffect函数的回调函数时先执行了onInvalidate函数中的回调函数来清除副作用(即把上一次的定时器给清除了,所以只有最后一次的定时器没有被清除)。

image-20230209130504650

图10-18 watchEffect清除副作用

watchEffect执行时机

在讲解 watchEffect执行时机之前,我们先补充一个知识:在setup中如何使用ref或者元素或者组件?

  • 其实非常简单,我们只需要定义一个前面讲的ref对象,绑定到元素或者组件的ref属性上即可。

我们在08_watch使用文件夹下新建:WatchEffectAPIFlush.vue组件。

WatchEffectAPIFlush.vue子组件,代码如下所示(省略的template和上面案例一样):

html
<template>
  <div>
    <h4 ref="titleRef">哈哈哈</h4>
  </div>
</template>

<script>
  import { ref, watchEffect } from 'vue';

  export default {
    setup() {
      // 1.定义一个titleRef来拿到h4元素的DOM对象(组件对象也是一样)
      const titleRef = ref(null);
      // 2.h4元素挂载完成之后会自动赋值到titleRef变量上,这里监听titleRef变量被赋值,并打印出来看
      watchEffect(() => {
        console.log(titleRef.value); // 3.打印h4元素的DOM对象
      })
      return { titleRef }
    }
  }
</script>

可以看到,我们先用ref函数定义了一个titleRef响应式变量,接着该变量在setup函数中返回,并绑定到h4元素的ref属性上(注意:不需要用v-bind指令来绑定)。当h4元素挂载完成之后会自动赋值到titleRef变量上。为了观察titleRef变量被赋值,这里我们使用watchEffect函数来侦听titleRef变量的改变,并打印出来。最后我们在App.vue根组件中导入和使用WatchEffectAPIFlush组件(和前面的操作基本一样,这里不再贴代码)。

保存代码,运行在浏览器的效果,如图10-19所示。刷新页面,我们会发现打印结果打印了两次。

  • 这是因为setup函数在执行时就会立即执行传入的副作用函数(watchEffect的回调函数),这个时候DOM并没有挂载,所以打印为null。
  • 而当DOM挂载时,会给titleRef变量的ref对象赋值新的值,副作用函数会再次执行,打印出对应的元素。

image-20230209130514110

图10-19 ref获取元素对象

如果我们希望在第一次的时候就打印出来对应的元素呢?

  • 这个时候我们需要改变副作用函数的执行时机。
  • 它的默认值是pre,它会在元素 挂载 或者 更新 之前执行。
  • 所以我们会先打印出来一个空的,当依赖的titleRef发生改变时,就会再次执行一次,打印出元素。

我们可以设置副作用函数的执行时机,修改WatchEffectAPIFlush.vue子组件,代码如下所示:

html
......
<script>
  export default {
    setup() {
      ......
      watchEffect(() => {
        console.log(titleRef.value);
      },{
        flush: "post" // 修改执行时机,支持 pre, post, sync
      })
      return { titleRef }
    }
  }
</script>

这里的flush:"post"是将推迟副作用的初始运行,直到组件的首次渲染完成才执行。当然flush 选项还接受 sync,这将强制效果始终同步触发。然而,这是低效的,应该很少需要。

保存代码,运行在浏览器后。刷新页面,我们会发现结果打印了1次(打印出元素)。

注意:Vue3.2+ 以后watchPostEffectwatchEffect 带有 flush: 'post' 选项的别名。watchSyncEffectwatchEffect 带有 flush: 'sync' 选项的别名。

watch侦听

watch的API完全等同于组件watch选项的Property:

  • watch需要侦听特定的数据源,并在回调函数中执行副作用。
  • 默认情况下它是惰性的,只有当被侦听的源发生变化时才会执行回调。

与watchEffect的比较,watch允许我们:

  • 懒执行副作用(第一次不会直接执行)。
  • 更具体的说明当哪些状态发生变化时,触发侦听器的执行。
  • 访问侦听状态变化前后的值。

1、语法

js
import { watch } from 'vue'
watch(source, (newVal, oldVal)=>{}, options): WatchStopHandle

2、参数

  • source
    • 可响应式对象
    • 函数
  • cb(newVal, oldVal)=>{}
    • newVal
    • oldVal
  • options

3、返回值

4、示例

侦听单个数据源

watch侦听函数的数据源有两种类型:

  • 一个getter函数:但是该getter函数必须引用可响应式的对象(比如reactive或者ref)。
  • 直接写入一个可响应式的对象,reactive或者ref(比较常用的是ref)。

下面通过几个案例来学习watch函数的使用。

案例一:watch侦听的数据源为一个getter函数。

我们在08_watch使用文件夹下新建:WatchAPI.vue组件。WatchAPI.vue子组件,代码如下所示:

html
<template>
  <div>
    <h4 >{{info.name}}</h4>
    <button @click="changeData">修改数据</button>
  </div>
</template>
<script>
  import { reactive, watch } from 'vue';
  export default {
    setup() {
      const info = reactive({name: "coderwhy", age: 18});

      // 1.侦听watch时,传入一个getter函数(该函数引用可响应式的对象)
      watch(() => info.name, (newValue, oldValue) => {
        // 侦听info对象中name的改变  
        console.log("newValue:", newValue, "oldValue:", oldValue);
      })
        
      const changeData = () => {
        info.name = "kobe"; // 改变info对象中的name
      }

      return {changeData,info}
    }
  }
</script>

可以看到,我们调用了watch函数来侦听info对象name属性的变化。其中watch函数需要接收两个参数,第一次参数是一个getter函数,该函数必须引用可响应式的对象。第二参数是侦听的回调函数,该函数会接收到一个新的值和一个旧的值,并在该函数中打印出新旧值。最后我们在App.vue根组件中导入和使用WatchAPI组件(不再贴代码)。

保存代码,运行在浏览器的效果,如图10-20所示。刷新页面,点击修改数据按钮来修改info中的name后,我们可以看到watch已经侦听到info中name发生了改变,并打印出新旧值。

image-20230209130523979

图10-20 watch侦听的数据源为getter函数

案例二:watch侦听的数据源为reactive对象

修改WatchAPI.vue子组件,代码如下所示:

html
......
<script>
  export default {
    setup() {
      const info = reactive({name: "coderwhy", age: 18});
      // 1.侦听watch时,传入一个getter函数
      // watch(() => info.name, (newValue, oldValue) => {
      //   console.log("newValue:", newValue, "oldValue:", oldValue);
      // })
        
      // 2.传入一个可响应式对象: reactive对象
      watch(info, (newValue, oldValue) => {
        // reactive对象获取到的newValue和oldValue本身都是reactive对象  
        console.log("newValue:", newValue, "oldValue:", oldValue);
      })
      const changeData = () => info.name = "kobe";
      return {changeData,info}
    }
  }
</script>

保存代码,运行在浏览器后刷新页面,点击修改数据按钮后,我们可以看到watch已经侦听到info中name发生了改变,并打印出新旧值(都为reactive对象)。

image-20230210152201449

如果希望newValue和oldValue是一个普通的对象的话,我们可以这样侦听(深拷贝info),代码如下所示:

html
<script>
  export default {
    setup() {
      .......
      // 2.传入一个可响应式对象: reactive对象
      // 如果希望newValue和oldValue是一个普通的对象,watch第一参数改成getter函数
      watch(() => {
        return {...info}
      }, (newValue, oldValue) => {
        console.log("newValue:", newValue, "oldValue:", oldValue);
      })
      ......
    }
  }
</script>

保存代码,运行在浏览器后刷新页面,点击修改数据按钮后,我们可以看到watch已经侦听到info中name发生了改变,并打印出新旧值(都为普通对象)。

案例三:watch侦听的数据源为ref对象。

修改WatchAPI.vue子组件,代码如下所示:

html
......
<script>
  export default {
    setup() {
      .....
      const name = ref("codeywhy");
      // watch侦听ref对象,ref对象获取newValue和oldValue是value值的本身  
      watch(name, (newValue, oldValue) => {
        console.log("newValue:", newValue, "oldValue:", oldValue);
      })
      const changeData = () => name.value = "kobe";
      return {changeData,info,name}
    }
  }
</script>

保存代码,运行在浏览器后刷新页面,点击修改数据按钮后,我们可以看到watch已经侦听到name发生了改变,并打印出新旧值(都是name的value)。

侦听多个数据源

侦听器还可以使用数组同时侦听多个源:

我们在08_watch使用文件夹下新建:WatchAPIMult.vue组件。WatchAPIMult.vue子组件,代码如下所示:

html
<template>
  <div>
    <h4 >{{info.name}} - {{name}}</h4>
    <button @click="changeData">修改数据</button>
  </div>
</template>
<script>
  import { ref, reactive, watch } from 'vue';
  export default {
    setup() {
      // 1.定义可响应式的对象
      const info = reactive({name: "coder", age: 18});
      const name = ref("why");
      const age = ref(20);
      // 2.侦听多数据源,参数一是一个数组:数组中可以有getter函数,ref对象,reactive对象
      watch([() => ({...info}), name, age],
           ([newInfo, newName, newAge], [oldInfo, oldName, oldAge]) => {
        console.log(newInfo, newName, newAge);
        console.log(oldInfo, oldName, oldAge);
      })
      const changeData = () => {
        info.name = "kobe";
        name.value = "jack"
      }
      return {changeData,info,name}
    }
  }
</script>

可以看到,我们调用了watch函数来侦听多个数据源。watch函数的第一个参数接收的是一个数组,该数组中是支持侦听getter函数,ref对象和reactive对象的数据源。接着我们给watch的第二个参数传入回调函数,该回调函数接收的新值和旧值都是数组类型,然后我们在该函数中分别打印了新值和旧值。最后我们在App.vue根组件中导入和使用WatchAPIMult组件(不再贴代码)。

保存代码,运行在浏览器的效果,如图10-21所示。刷新页面,点击修改数据按钮后,我们可以看到watch已经侦听到info中name和name都发生了改变,并打印出新旧值。

image-20230209130534904

图10-21 watch侦听多数据源

侦听响应式对象

如果我们希望侦听一个数组或者对象,那么可以使用一个getter函数,并且对可响应对象进行解构。

侦听响应式对象在上面的案例二中已经介绍过,下面看看侦听响应式数组,代码如下所示:

js
const names = reactive(["abc", "cba", "nba"]);
// 侦听响应式数组( 和对象的使用一样 )
watch(() => [...names], (newValue, oldValue) => {
  console.log(newValue, oldValue);
})
const changeName = () => {
  names.push("why");
}

如果是侦听对象时,我们希望侦听是一个深层的侦听,那么依然需要设置 deep 为true:

  • 也可以传入 immediate 立即执行。

我们在08_watch使用文件夹下新建:WatchAPIDeep.vue组件。WatchAPIDeep.vue子组件,代码如下所示:

html
<template>
  <div>
    <h4 >{{info.name}}</h4>
    <button @click="changeData">修改数据</button>
  </div>
</template>

<script>
  import { ref, reactive, watch } from 'vue';

  export default {
    setup() {
      // 1.定义可响应式的对象
      const info = reactive({
        name: "coderwhy", 
        age: 18,
        friend: {
          name: "kobe"
        }
      });
      // 2.侦听响应式对象
      watch(() => ({...info}), (newInfo, oldInfo) => {
        console.log(newInfo, oldInfo);
      }, {
        deep: true,
        immediate: true
      })

      const changeData = () => info.friend.name = "james"
      return {changeData,info}
    }
  }
</script>

可以看到,我们调用了watch函数来侦听一个对象。watch函数的第一个参数是一个getter函数,第二个参数传入回调函数,在该回调函数打印接收的新值和旧值,第三个参数一个watch的配置项。其中deep为true代表是一个深层的侦听,即当用户修改了info中friend对象的name也会被watch侦听到,如果为false则侦听不到。还有immediate为true代表watch的回调函数会先立即执行一次,当侦听到有数据变化时才再次执行该回调函数。最后我们在App.vue根组件中导入和使用WatchAPIDeep组件(不再贴代码)。

保存代码,运行在浏览器后。刷新页面,默认会先立即执行一次watch的回调函数,当点击修改数据按钮后,我们可以看到watch可以深层侦听info中firend对象的name发生了改变。

组件生命周期钩子

我们前面说过 setup 可以用来替代 datamethodscomputedwatch 等等这些选项,也可以替代 生命周期钩子

那么setup中如何使用生命周期函数呢?

  • 可以使用直接导入的 onXxx 函数注册生命周期钩子。

我们在01_composition_api项目的src目录下新建09_生命周期钩子文件夹,然后在该文件夹下新建:App.vue组件。

App.vue根组件,代码如下所示:

html
<template>
  <div><button @click="increment">点击+1:{{counter}}</button></div>
</template>
<script>
  import { onMounted, onUpdated, onUnmounted, ref } from 'vue';
  export default {
    setup() {
      const counter = ref(0);
      const increment = () => counter.value++

      // 生命周期钩子函数 (同一个生命周期函数可以存在多个)
      onMounted(() => {
        console.log("App Mounted1");
      })
      onMounted(() => {
        console.log("App Mounted2");
      })
      onUpdated(() => {
        console.log("App onUpdated");
      })
      onUnmounted(() => {
        console.log("App onUnmounted");
      })
      return {counter,increment}
    }
  }
</script>

可以看到,在App组件中注册了onBeforeMount、onMounted、onUpdated和onUnmounted生命周期函数,其中onMounted生命周期函数我们注册了两次。

然后我们修改main.js程序入口文件,将导入的App组件改为09_生命周期钩子/App.vue路径下的App组件。

保存代码,运行在浏览器的效果,如图10-22所示。刷新页面,控制台会打印App onBeforeMount、App Mounted1、App Mounted2,每当点击一次按钮会打印一次App onUpdated。这里就不一一演示组件的销毁和其它的生命周期函数了。

image-20230209130545130

那么Compostion API提供了哪些生命周期函数呢?并且Compostion API的生命周期函数和Options API的生命周期函数有什么对应关系呢?请看下表:

选项式 APIHook inside setup
beforeCreateNot needed*
createdNot needed*
beforeMountonBeforeMount
mountedonMounted
beforeUpdateonBeforeUpdate
updatedonUpdated
beforeUnmountonBeforeUnmount
unmountedonUnmounted
activatedonActivated
deactivatedonDeactivated

我们会发现Compostion API没有提供 beforeCreatecreated 生命周期函数,而是直接使用setup函数来代替了(setup函数会在beforeCreate之前调用),如图10-23所示。

image-20230209130556775

图10-23 setup代替了beforeCreate和created

Provide/Inject

事实上我们之前还学习过ProvideInject,Composition API也可以替代之前的 ProvideInject 的选项。

  • provide():给子组件或者子孙组件提供数据
  • inject()

Provide函数

我们可以通过 provide函数来给子组件或者子孙组件提供数据:

  • 可以通过 provide 函数来定义每个 property。

  • provide函数可以传入两个参数:

    • name:提供的属性名称。
    • value:提供的属性值。

下面我们来通过一个案例来学习一下Provide函数的使用。我们在01_composition_api项目的src目录下新建10_Provide和Inject文件夹,然后在该文件夹下新建:App.vue组件。

App.vue根组件,代码如下所示:

html
<template>
  <div class="app" style="border:1px solid #ddd;margin:4px">
    App组件
    <div>{{name}} - {{age}}</div>
    <div>{{counter}}</div>
    <button @click="increment">App组件+1</button>
  </div>
</template>

<script>
+  import { provide, ref } from 'vue';
  export default {
    setup() {
      // 1.定义普通数据
      const name = "coderwhy";
      const age = 18;
      // 2.定义响应式数据
      let counter = ref(100);
      
      // 3.给子组件或者子孙组件提供数据
+      provide("name", name);
+      provide("age", age); // 提供普通数据(只能读,不能修改)
+      provide("counter", counter); // 提供响应式数据

      const increment = () => counter.value++;
      return {name,age,increment,counter}
    }
  }
</script>

可以看到,在setup函数中调用了provide函数来给子组件或者子孙组件提供了name与age普通数据和counter响应式数据。其中提供的普通数据是只读不能修改,提供的响应式数据默认是可读可修改,并且是响应式的。

然后我们修改main.js程序入口文件,将导入的App组件改为10_Provide和Inject/App.vue路径下的App组件。

保存代码,运行在浏览器的效果,如图10-24所示。可以看到在自己本组件中能正常显示,点击按钮也能实现响应式刷新页面。那有些同学会问provide不是给子组件或者子孙组件提供数据吗?那么子组件和子孙组件如何获取?那我们继续来学习下一小节的inject函数。

image-20230209130606817

图10-24 provide函数的基本使用

Inject函数

在后代组件中可以通过 inject 来注入需要的属性和对应的值:

  • 可以通过 inject 函数来注入需要的内容。

  • inject可以传入两个参数:

    • 要 inject 的 property 的 name。
    • 默认值。

上面案例的App父组件已经完成数据的提供,那么它的子组件和孙子组件怎么获取提供的数据呢?要想获取父组件通过provide提供的数据,子组件或者孙子组件需要通过inject函数来获取。

接着我们在10_Provide和Inject文件夹下新建:Home.vue组件。

Home.vue子组件,代码如下所示:

html
<template>
  <div style="border:1px solid #ddd;margin:8px">
    Home组件
    <div>{{name}} - {{age}}</div>
    <div>{{counter}}</div>
    <button @click="homeIncrement">Home组件+1</button>
  </div>
</template>

<script>
+  import { inject } from 'vue';
  export default {
    setup() {
      // 1.获取父组件provide提供的数据( 子组件和孙子组件获取的代码是一模一样的)
+      const name = inject("name");
+      const age = inject("age");
+      const counter = inject("counter");

      const homeIncrement = () => counter.value++;
      return {name,age, counter,homeIncrement}
    }
  }
</script>

可以看到,该组件在setup函数中通过inject函数来注入父组件或者祖父组件使用provide函数提供的数据。其中name与age是注入普通对象(只读不能修改),counter则是响应式对象(可读可修改)。接着当点击button时,我们在子组件中修改了父组件提供的counter值。

修改App组件,代码如下所示:

html
<template>
  <div class="app" style="border:1px solid #ddd;margin:4px">
    App组件......
    <home/>
  </div>
</template>
<script>
  import Home from './Home.vue';
  export default {
    components: { Home },
    ......
  }
</script>

保存代码,运行在浏览器的效果,如图10-25所示。当我们点击App组件的按钮来在父组件修改counter时,App组件和Home组件的counter都同步变化,当我们点击Home组件的按钮来在子组件修改counter时,App组件和Home组件的counter也是同步变化。这就说明父组件提供的响应式数据,子组件不但能获取到,还保持了响应式。

image-20230209130617063

图10-24 inject函数的基本使用

共享响应式属性

1.共享响应式的数据

为了增加 provide 值和 inject 值之间的响应性,其实我们可以在 provide 值时使用 refreactive对象。其中ref对象上面已经演示了,这里再看一下如何提供reactive响应式数据,代码如下所示:

js
// App父组件
let counter = ref(100)
let info = reactive({
  name: "why",
  age: 18
})
// 1.提供响应式数据
provide("counter", counter)
provide("info", info)
// 2.修改响应式数据
const changeInfo = () => {
  info.name = "coderwhy"
}

// 子组件(孙子组件)注入父组件(祖父组件)提供的响应式数据
const counter = inject("counter");
const info = inject("info");

2.修改响应式Property

因为父组件可以通过provide提供响应式数据给子组件,该响应式数据默认是可以在父组件被修改,也可以在子组件被修改。如果子组件也可以修改父组件提供的响应式数据,那么我们就很难追踪响应数据到底是在哪被修改的,为了保证单向数据流,我们一般建议:

  • 如果我们需要修改响应的数据,那么最好是在数据提供的位置来修改(如上案例应在App中修改counter)
  • 其实我们还可以将修改数据的方法进行共享,在后代组件中进行调用(如上案例不应在Home中直接修改counter)。
  • 有时候为了避免子组件修该父组件提供的数据,我们可以借助readonly函数,如下代码所示。
js
provide("info", readonly(info); // 子组件注入时只能读,不能修改
provide("counter", readonly(counter); // 子组件注入时只能读,不能修改

Composition API综合练习

前面我们已经学习了setup、reactive、ref、computed、watchEffect、watch、provide、inject等等Composition API,那下面将通过一个Composition API的综合练习来巩固一下组合API的使用以及代码逻辑的封装(即Hook函数的封装)。其中该综合练习包含以下功能:

  • 计数器案例的实现。
  • 修改网页的标题。
  • 完成一个监听界面滚动位置。

在使用Composition API之前,我们先看看用Options API是如何实现该功能。

我们在01_composition_api项目的src目录下新建11_compositionAPI综合练习文件夹,然后在该文件夹下分别新建:App.vue,OptionsAPIExample.vue组件。

OptionsAPIExample.vue子组件,代码如下所示:

html
<template>
  <div>
    <!--1.计数器案例  -->
    <div>当前计数: {{counter}}</div>
    <div>当前计数*2: {{doubleCounter}}</div>
    <button @click="increment">+1</button>
    <button @click="decrement">-1</button>
  </div>
</template>

<script>
  export default {
     data() {
       return{
         // 1.2计数器案例的逻辑代码   
         counter:100
       }
     },
     computed: {
       // 1.3计数器案例的逻辑代码 
       doubleCounter() {
          return this.counter * 2 
       }
     },
     methods: {
        // 1.4计数器案例的逻辑代码
        increment() {
           this.counter++;
        },
        decrement() {
           this.counter--;
        }
     }
  }
</script>

可以看到,该案例我们仅实现了计数器的案例。为了保证代码的简洁易懂,其它修改网页标题和监听页面滚动的代码逻辑这里暂时先不实现(后面直接用组合API来实现)。最后我们在App.vue根组件中导入和使用OptionsAPIExample组件(不再贴代码)。

然后我们修改main.js程序入口文件,将导入的App组件改为11_compositionAPI综合练习/App.vue路径下的App组件。

保存代码,运行在浏览器的效果,如图10-26所示。

image-20230209130632756

图10-26 Options API实现的计数器

下面我们再用Composition API来实现该功能。我们在11_compositionAPI综合练习文件夹下新建:CompositionAPIExample.vue组件。

CompositionAPIExample.vue子组件,代码如下所示:

html
<template>
  <div>
    <!-- 1.计数器案例 -->
    <div>当前计数: {{counter}}</div>
    <div>当前计数*2: {{doubleCounter}}</div>
    <button @click="increment">+1</button>
    <button @click="decrement">-1</button>
  </div>
</template>
<script>
  import { ref, computed } from 'vue';
  export default {
    setup() {
      // 1.1计数器案例的逻辑代码
      const counter = ref(100);
      const doubleCounter = computed(() => counter.value * 2);

      const increment = () => counter.value++;
      const decrement = () => counter.value--;
      return {
        counter,
        doubleCounter,
        increment,
        decrement
      }
    }
  }
</script>

可以看到,该案例我们仅实现了计数器的案例。其它修改网页标题和监听页面滚动的代码逻辑这里也暂时先不实现。最后我们在App.vue根组件中导入和使用CompositionAPIExample组件(不再贴代码)。

保存代码,运行在浏览器的效果和Options API实现的效果一模一样。通过这两个案例,我们可以发现:

  • Options API的特点就是在对应的属性中编写对应的功能模块
  • 但Options API有一个很大的弊端是对应的代码逻辑被拆分到各个属性中,当组件变得更大、复杂时,同一个功能的逻辑会被拆分的很分散(如上面的计数器功能逻辑被拆分到各个选项中),不利于代码的阅读和理解。
  • Composition API的特点是能将同一个逻辑关注点相关的代码收集在一起,方便代码的封装和复用,也更利于代码的阅读和理解。
  • Composition API用了比较多的函数,用起来稍微比Options API复杂一点,但是函数式编程对TS支持更友好。

对比完Options API和Composition API编写计数器案例的优缺点之后,下面我们来看看如何对Composition API编写的代码逻辑进行封装和复用。在Options API编写方式中,我们已知道代码逻辑的封装和复用可以使用Mixin混入,那在Composition API中我们可以将关注点相关的代码逻辑封装到一个函数中,该函数我们一般会使用useXx来命名(社区默认准寻的规范),并且以useXx开头的函数我们称之为自定义Hook函数。

useCounter

认识Hook函数之后,下面我们来把上面计数器案例的代码逻辑封装到一个useCounter的Hook函数中。

我们在11_compositionAPI综合练习文件夹下新建:hooks/useCounter.js文件。

useCounter.js文件封装useCounter Hook函数,代码如下所示:

js
import { ref, computed } from 'vue';

export default function useCounter() {
  // 1.1计数器案例的逻辑代码
  const counter = ref(100);
  const doubleCounter = computed(() => counter.value * 2);

  const increment = () => counter.value++;
  const decrement = () => counter.value--;

  return {
    counter, 
    doubleCounter, 
    increment, 
    decrement
  }
}

可以看到,我们在该文件中默认导出一个函数(也支持匿名函数),在该函数中我们把CompositionAPIExample组件实现计数器案例的代码逻辑全部抽取过来了。

接着修改CompositionAPIExample组件,代码如下所示:

html
.....
<script>
  import useCounter from './hooks/useCounter'
  export default {
    setup() {
      // 1.计数器案例的代码逻辑抽取到useCounter hook 中了
      const {counter, doubleCounter, increment, decrement} = useCounter()

      return {counter, doubleCounter, increment, decrement}
    }
  }
</script>

可以看到,该组件之前实现计数器案例的逻辑代码已经抽取到了useCounter函数中,这时我们只要导入useCounter函数,并在setup中调用该函数便可以拿到返回的响应式数据和事件函数,然后直接返回给模板使用。保存代码,运行在浏览器的效果和没抽取前一模一样。

useTitle

实现完计数器案例之后,下面我们接着再CompositionAPIExample组件中来实现修改网页标题的功能。修改CompositionAPIExample组件,代码如下所示:

html
<script>
  export default {
    setup() {
      .....
      // 2.修改网页的标题案例
      const titleRef = ref("coder");
      document.title = titleRef.value// 更新网页标题
      return {counter, doubleCounter, increment, decrement}
    }
  }
</script>

可以看到,只在CompositionAPIExample中的setup函数中添加两行代码即可以。保存代码,运行在浏览器的效果,如图10-27所示。已经将网页的标题修改为coder。

image-20230209130643322

图10-27 修改网页的标题

像这种修改网页标题的代码逻辑可能在其它组件中还会再次使用到,那么我们就可以将该功能封装到一个Hook函数中。我们在11_compositionAPI综合练习文件夹下新建:hooks/useTitle.js文件。

useTitle.js文件封装useTitle Hook函数,代码如下所示:

js
import { ref, watch } from 'vue';
// 使用匿名函数,并该函数需接收一个参数
export default function(title = "默认的title") {
  const titleRef = ref(title);
  // 侦听titleRef变化,一旦被修改就更新 
  watch(titleRef, (newValue) => {
    document.title = newValue 
  }, {
    immediate: true // 侦听的回调函数先执行一次
  })
  return titleRef
}

修改CompositionAPIExample组件,代码如下所示:

html
<script>
  .....
  import useTitle from './hooks/useTitle'
  export default {
    setup() {
      .....		
      // 2.修改网页的标题案例
      const titleRef = useTitle("coder");
      setTimeout(() => {
        // 3秒后修改titleRef的值,useTitle函数的watch侦听到会修改标题    
        titleRef.value = "why 
      }, 3000);
      return {counter, doubleCounter, increment, decrement}
    }
  }
</script>

可以看到,我们先导入useTitle函数,接着在setup中调用useTitle函数初始化标题为coder,然后过了2秒之后将标题修改为why。保存代码,运行在浏览器后。网页的标题在3秒后有coder修改为why。

useScrollPosition

实现完修改网页的标题之后,我们接着继续再CompositionAPIExample组件中来实现监听页面滚动位置的功能。修改CompositionAPIExample组件,代码如下所示:

html
<template>
  <div>
    .....
    <!-- 3.显示页面滚动位置 -->
    <p style="width: 3000px;height: 5000px;">
      width:3000px  height:5000px的,模拟页面滚动
    </p>
    <div style="position: fixed;top:20px;right:20px">
      <div >scrollX: {{scrollX}}</div>
      <div >scrollY: {{scrollY}}</div>
    </div>
  </div>
</template>

<script>
  .....
  export default {
    setup() {
      ......
      // 3.监听页面滚动
      const scrollX = ref(0);
      const scrollY = ref(0);
      document.addEventListener("scroll", () => {
          scrollX.value = window.scrollX;
          scrollY.value = window.scrollY;
      });
      return {counter, doubleCounter, increment, decrement, scrollX, scrollY}
    }
  }
</script>

可以看到,我们先在template中编写宽和高超出屏幕大小的p元素(模拟页面可滚动),接着在setup函数监听了页面的滚动,并在该回调函数中给scrollX和scrollY变量赋当前滚动的值。最后在return函数中返回scrollX和scrollY变量给temlpate来显示当前滚动的位置。保存代码,运行在浏览器的效果,如图10-28所示。上下滚动页面的时候,页面的右上角上能显示当前滚动位置值。

image-20230209130653506

那如果该功能也会被再次使用到,我们依然可以将该功能封装到一个Hook函数中。我们在11_compositionAPI综合练习文件夹下新建:hooks/useScrollPosition.js文件。

useScrollPosition.js文件封装useScrollPosition Hook函数,代码如下所示:

js
import { ref } from 'vue';
// 自定义 useScrollPosition Hook函数
export default function useScrollPosition() {
  const scrollX = ref(0);
  const scrollY = ref(0);

  document.addEventListener("scroll", () => {
    scrollX.value = window.scrollX;
    scrollY.value = window.scrollY;
  });

  return {scrollX, scrollY} // 返回ref响应式数据
}

修改CompositionAPIExample组件,代码如下所示:

html
.......
<script>
  import useCounter from './hooks/useCounter'
  import useTitle from './hooks/useTitle'
  import useScrollPosition from './hooks/useScrollPosition'
  export default {
    setup() {
      // 1.计数器案例(可直接解构,如果返回的是reactive对象则不能直接解构使用)
      const {counter, doubleCounter, increment, decrement} = useCounter()
      // 2.修改网页标题案例
      const titleRef = useTitle("coder");
      setTimeout(() => {
        titleRef.value = "why"
      }, 3000);
        
      // 3.监听页面滚动位置案例 (可直接解构,因为Hook函数返回对象属性是ref对象)
      const { scrollX, scrollY } = useScrollPosition();
      return {counter, doubleCounter, increment, decrement, scrollX, scrollY}
    }
  }
</script>

可以看到,我们先导入useScrollPosition函数,接着在setup中调用useScrollPosition函数来获取到当前滚动的值。如果滚动页面了,useScrollPosition函数里会监听到并修改scrollX和scrollY响应式变量的值,同时更新页面。保存代码,运行在浏览器后。滚动网页时可以发现页面上右上角的scrollX和scrollY能显示当前滚动的位置。

script setup语法

当我们在编写单文件组件(即.vue文件)的时候,除了 <script> 语法,其实Vue3还支持<script setup>语法,它方便我们在script顶层来编写setup相关的代码。setup script语法的代码看起来简单了很多,开发效率大大的提高。该语法是在2020-10-28号提出,在Vue3.2版本之前它还只是一个实验性功能,但是到了Vue3.2版本<script setup>语法已从实验状态毕业,现在被认为是稳定的了。

<script setup> 是在单文件组件 (SFC) 中使用组合式 API 的编译时语法糖。相比于普通的 <script> 语法,它具有更多优势:

  • 更少的样板内容,更简洁的代码。
  • 能够使用纯 Typescript 声明 props 和抛出事件。
  • 更好的运行时性能 (其模板会被编译成与其同一作用域的渲染函数,没有任何的中间代理)。
  • 更好的 IDE 类型推断性能 (减少语言服务器从代码中抽离类型的工作)。

基本使用

我们来看一下用<script setup>语法是如何使用的:

  • 要使用这个语法,需要将 setup attribute 添加到 <script> 代码块上。
  • 里面的代码会被编译成组件 setup() 函数的内容。
  • 这意味着与普通的 <script> 只在组件被首次引入的时候执行一次不同,<script setup> 中的代码会在每次组件实例被创建的时候执行
  • 当使用 <script setup> 的时候,任何在 <script setup> 声明的顶层的绑定 (包括变量,函数声明,以及 import 引入的内容) 都能在模板中直接使用,不再需要在 return中返回。

下面我们使用<script setup>语法来编写计数器案例,我们在01_composition_api项目的src目录下新建12_script_setup顶层编写方式文件夹,然后在该文件夹下分别新建:App.vue,ScriptSetupExample.vue组件。

ScriptSetupExample.vue子组件,代码如下所示:

html
<template>
  <div>
    <h4>当前计数: {{counter}}</h4>
    <button @click="increment">+1</button>
  </div>
</template>
// 1.script setup语法的顶层编写方式
<script setup>
  // 2.ref、counter、increment是在顶层绑定,所以都能在模板中直接使用
  import { ref } from 'vue';
  const counter = ref(0);
  const increment = () => counter.value++;
</script>

可以看到,该组件使用了<script setup>语法的顶层编写方式,在顶层绑定了ref、counter、increment,所以都能在模板中直接使用他们。最后我们在App.vue根组件中导入和使用ScriptSetupExample组件(不再贴代码)。

然后我们修改main.js程序入口文件,将导入的App组件改为12_script_setup顶层编写方式/App.vue路径下的App组件。

保存代码,运行在浏览器的效果,如图10-29所示。已实现计数器案例。

image-20230209130702997

图10-8 script setup语法的基本使用

当使用 <script setup> 的时候,任何在 <script setup> 声明的顶层的绑定都能在模板中直接使用。例如:声明的普通变量,响应式变量,函数,import 引入的内容(包含函数,对象,组件,动态组件,指令等等)。当是响应式状态时需要明确使用响应式 APIs 来创建。和从 setup() 函数中返回值一样,ref 值在模板中使用的时候会自动解包,如下代码所示:

html
<template>
  <MyComponent />
  <component :is="Foo" />
  <h4 v-my-directive>This is a Heading</h1>
  <div>{{ capitalize('hello') }}</div>
  <button @click="count++">{{ count }}</button>
  <div @click="log">{{ msg }}</div>
</template>
// script setup语法的顶层的绑定( 下面声明的绑定都可以直接在模板中使用 )
<script setup>
import MyComponent from './MyComponent.vue' // 声明绑定组件   
import Foo from './Foo.vue' // 声明绑定动态组件   
import { myDirective as vMyDirective } from './MyDirective.js' // 声明绑定指令 
import { capitalize } from './helpers' // 声明绑定工具函数
import { ref } from 'vue' // 声明绑定ref函数 
    
const count = ref(0) // 声明绑定响应式变量
const msg = 'Hello!' // 声明绑定普通变量

function log() { // 声明绑定函数
  console.log(msg)
}
</script>

上面代码列举了在 <script setup>中常用的顶层的绑定。上面代码所声明的组件,函数,指令等这里就不一一实现了。大家只要知道在<script setup>中顶层的绑定会被暴露给模板使用就可以了。

defineProps和defineEmits

上面我们已经学会了<script setup>语法的基本使用,那么在这种语法下,我们应该如何定义props和如何发出事件呢?在 <script setup> 中必须使用 definePropsdefineEmits APIs 来声明 propsemits ,它们具备完整的类型推断并且在 <script setup> 中是直接可用的(Vue3.2版本以后不需要导入)。

我们在12_script_setup顶层编写方式文件夹下新建:DefinePropsEmitAPI.vue组件。

DefinePropsEmitAPI.vue子组件,代码如下所示:

html
<template>
  <div style="border:1px solid #ddd;margin:8px">
    <div>DefinePropsEmitAPI组件</div>
    <p>{{message}}</p>
    <button @click="emitEvent">发射emit事件</button>
  </div>
</template>
// Vue3.2以后defineProps和defineEmits不需要导入(当前项目Vue安装的版本是:3.2.29)
<script setup>
  // 1.定义props属性(等同于Options API的props选项)  
  const props = defineProps({
    // message: String,  
    message: {
      type: String,
      default: "默认的message"
    }
  })
  // 2.注册需要触发的emit事件
  const emit = defineEmits(["increment"]);
  // 3.点击 发射emit事件 按钮的回调
  const emitEvent = () => {
    console.log('子组件拿到父组件传递进来的message:' + props.message)  
    emit('increment', 1) // 触发 increment 事件,传递参数:1
  }
</script>

可以看到,我们使用defineProps函数来给组件定义了message属性,使用defineEmits函数来给组件注册了increment事件,并返回emit函数。当点击button时,先打印父组件传递进来的message,然后使用emit函数来触发事件。

如何查看当前项目依赖Vue的具体版本:可看node_modules/vue/package.json文件中的version属性。

接着修改App组件,代码如下所示:

html
<template>
  <div class="app" style="border:1px solid #ddd;margin:4px">
    App组件
    <!-- <ScriptSetupExample></ScriptSetupExample> -->
    <DefinePropsEmitAPI message="App传递过来的message" @increment="getCounter"/>
  </div>
</template>
<script setup>
  import { ref } from 'vue'
  import ScriptSetupExample from './ScriptSetupExample.vue';
  import DefinePropsEmitAPI from './DefinePropsEmitAPI.vue';
  const getCounter = (number)=> console.log('App 组件拿到子组件传递过来的number:' + number)
</script>

可以看到,我们先导入DefinePropsEmitAPI组件,接着在template中使用该组件时,给它传递了message属性和监听了increment事件 。

保存代码,运行在浏览器后点击发射emit事件按钮,便会调用emitEvent函数,控制台输出如图10-30所示。

image-20230209130716324

图10-30 defineProps和defineEmits的使用

有关于definePropsdefineEmits 函数,我们还需要注意的是:

  • definePropsdefineEmits APIs都是只在 <script setup> 中才能使用的编译器宏。他们不需要导入且会随着 <script setup> 处理过程一同被编译掉。
  • defineProps 接收与 props 选项相同的值,defineEmits 也接收 emits 选项相同的值。
  • definePropsdefineEmits 在选项传入后,会提供恰当的类型推断。
  • 传入到 definePropsdefineEmits 的选项会从 setup 中提升到模块的范围。因此,传入的选项不能引用在 setup 范围中声明的局部变量。这样做会引起编译错误。但是,它可以引用导入的绑定,因为它们也在模块范围内。

defineExpose

使用 <script setup> 语法的组件是默认关闭的,即通过模板 ref 或者 $parent 链获取到的组件的公开实例,该实例是不会暴露任何在 <script setup> 中声明的绑定。所以为了在 <script setup> 语法组件中明确要暴露出去的属性,我们需要使用 defineExpose 编译器宏。

我们在12_script_setup顶层编写方式文件夹下新建:DefineExposeAPI.vue组件。

DefineExposeAPI.vue子组件,代码如下所示:

html
<template>
  <div style="border:1px solid #ddd;margin:8px">
     DefineExposeAPI 组件 
  </div>
</template>
<script setup>
  import { ref } from 'vue'
  const age = 18 // 普通数据
  const name = ref('coderwhy') // 响应式数据
  const showMessage = ()=>{console.log('showMessage方法')} // 方法
  // 该组件暴露出去的属性( age,name,showMessage )
  defineExpose({age,name,showMessage})
</script>

可以看到,我们在该组件中定义了age,name和showMessage方法,然后通过defineExpose API将这3个属性暴露出去。

接着修改App组件,代码如下所示(省略的代码已注释):

html
<template>
  <div class="app" style="border:1px solid #ddd;margin:4px">App组件
    .....
    <DefineExposeAPI ref="defineExposeAPI"></DefineExposeAPI>
  </div>
</template>

<script setup>
  import { ref, watchEffect } from 'vue'
  ....
  import DefineExposeAPI from './DefineExposeAPI.vue';
  // 获取DefineExposeAPI组件的实例和该组件暴露的属性
  const defineExposeAPI = ref(null)
  watchEffect(()=>{
    console.log(defineExposeAPI.value) // 组件的实例
    console.log(defineExposeAPI.value.name) // 响应式数据
    console.log(defineExposeAPI.value.age)
    defineExposeAPI.value.showMessage()
  }, {flush:"post"})
  ....
</script>

可以看到,我们用ref定义了defineExposeAPI变量,并绑定到DefineExposeAPI组件的ref属性上来获取该组件的实例。然后在watchEffect函数中获取该组件实例和该组件暴露出来的:name,age和showMessage属性。

保存代码,运行在浏览器后,控制台输出如图10-31所示。即父组件App可以访问到子组件暴露出来的name,age和showMessage属性。

image-20230209130726744

图10-30 defineExpose的使用

useSlots和useAttrs

在学习setup函数时,该函数主要有两个参数:props和context,其中context里面包含slots,attrs,emit三个属性。那在 <script setup>中应该如何拿到slots,attrs属性?虽然在 <script setup> 使用 slotsattrs 的情况应该是很罕见的(因为可以在模板中通过 $slots$attrs 来访问它们)。在你的确需要使用它们的罕见场景中,可以分别用 useSlotsuseAttrs 两个辅助函数。代码如下所示:

html
<script setup>
import { useSlots, useAttrs } from 'vue'
const slots = useSlots() // 拿到该组件的插槽,等于setup函数中的context.slots
const attrs = useAttrs() // 拿到该组件所有的属性,等于setup函数中的context.attrs
</script>

useSlotsuseAttrs 是真实的运行时函数(需要导入后使用),它会返回与 setupContext.slotssetupContext.attrs 等价的值,同样也能在普通的组合式 API 中使用。